Skip to content

Add Workflow Mermaid/DOT visualization export (parity with .NET WorkflowVisualizer) - #633

Open
PratikDhanave (PratikDhanave) wants to merge 4 commits into
microsoft:mainfrom
PratikDhanaveFork:workflow-viz-export
Open

Add Workflow Mermaid/DOT visualization export (parity with .NET WorkflowVisualizer)#633
PratikDhanave (PratikDhanave) wants to merge 4 commits into
microsoft:mainfrom
PratikDhanaveFork:workflow-viz-export

Conversation

@PratikDhanave

Copy link
Copy Markdown
Contributor

What

Adds workflow/visualization.go exporting two functions:

  • func ToMermaidString(wf *Workflow) string — renders a built Workflow as a Mermaid flowchart TD.
  • func ToDotString(wf *Workflow) string — renders the same graph as a Graphviz DOT digraph.

Both consume the existing reflection API (ReflectExecutors, ReflectEdges, EdgeInfo, EdgeConnection) — no production data structures were changed.

Why (parity)

.NET's WorkflowVisualizer.ToDotString / ToMermaidString is a real, shipped capability, and workflow/telemetry.go already serializes the graph to JSON but stops short of emitting a human-renderable diagram. This closes that cross-SDK gap so Go workflows get the same visualization affordance qmuntal values for alignment. The renderers mirror the .NET semantics:

  • Nodes are emitted per executor binding, with the start executor highlighted.
  • Fan-out edges expand to one edge per target.
  • Fan-in edges (len(SourceIDs) > 1) route through a synthesized junction node.
  • Conditional edges (HasCondition) are dashed; EdgeInfo.Label is preserved.
  • Nested sub-workflows (a binding whose ExecutorBinding.RawValue is a *Workflow, as produced by inproc.BindSubworkflowAsExecutor) render as Mermaid subgraphs / DOT clusters via recursion.

Node IDs are sanitized for Mermaid and labels are escaped for DOT. Output is deterministic (executors and edges sorted; fan-in edges deduplicated since ReflectEdges registers them under every source).

Tests

workflow/visualization_test.go (black-box workflow_test, reusing the existing newNoOpExecutor harness):

  • Builds start → fan-out → fan-in-barrier → conditional-labeled edge and asserts stable substrings in both outputs: start-node highlight, each executor node, dashed conditional marker, edge label, and the fan-in junction node.
  • A nested sub-workflow case asserting the subgraph (Mermaid) / cluster (DOT) block.

go build ./..., go vet ./workflow/, and go test ./workflow/... all pass.

Open design questions

Opening as a draft since this adds public API surface:

  • API shape / location — free functions in package workflow, or methods on *Workflow, or a separate workflow/visualization subpackage? Kept them as package-level funcs to mirror the static WorkflowVisualizer helpers.
  • Renderer nuances — fan-in is drawn through a synthesized {{"fan-in"}} junction (Mermaid) / diamond node (DOT); fan-out is left as parallel edges without a junction. Worth confirming this matches the .NET visual convention.
  • Styling — highlight/dash choices are minimal and hardcoded; open to matching the exact .NET palette/shapes if there's a canonical set.
  • Follow-ups — could also surface request ports and output executors as distinct node styles; deferred to keep this change surgical.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions github-actions Bot added the public-api-change Pull Request changes public APIs label Jul 24, 2026
Add ToMermaidString and ToDotString in workflow/visualization.go to render
a built Workflow as a Mermaid flowchart or Graphviz DOT digraph, matching
.NET's WorkflowVisualizer. Nodes come from ReflectExecutors (start executor
highlighted), edges from ReflectEdges: fan-out expands per target, fan-in
routes through a synthesized junction, conditional edges are dashed, labels
are preserved, and nested sub-workflows render as subgraphs/clusters.
@github-actions

This comment has been minimized.

@PratikDhanave
PratikDhanave (PratikDhanave) marked this pull request as ready for review August 4, 2026 06:06
@PratikDhanave
PratikDhanave (PratikDhanave) requested a review from a team as a code owner August 4, 2026 06:06
Copilot AI lite review requested due to automatic review settings August 4, 2026 06:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds workflow graph visualization exporters to the Go SDK, generating human-renderable Mermaid and Graphviz DOT diagrams from the existing workflow reflection metadata (executors + edges), including support for nested sub-workflows.

Changes:

  • Introduces workflow.ToMermaidString(*Workflow) to render a Mermaid flowchart TD view of a built workflow graph.
  • Introduces workflow.ToDotString(*Workflow) to render an equivalent Graphviz DOT digraph.
  • Adds black-box tests covering core edge shapes (fan-out, fan-in barrier junction, conditional + labeled edges) and nested sub-workflow rendering.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
workflow/visualization.go Implements Mermaid and DOT renderers, plus edge deduplication and escaping helpers.
workflow/visualization_test.go Adds tests asserting key output fragments for both renderers, including nested sub-workflows.
Suppressed comments (1)

workflow/visualization.go:116

  • For sub-workflows, this creates a DOT cluster but doesn't define a node for the host executor (prefix+id). Downstream edges will implicitly create a separate node outside the cluster, leaving the cluster disconnected from the workflow graph. Define the host node inside the cluster so edges attach to it predictably.
		if sub, ok := binding.RawValue.(*Workflow); ok && sub != nil {
			fmt.Fprintf(b, "%ssubgraph \"cluster_%s\" {\n", indent, dotEscape(nodeID))
			fmt.Fprintf(b, "%s    label=\"%s\";\n", indent, dotEscape(id))
			writeDotWorkflow(b, sub, prefix+id+"/", depth+1, visited)
			fmt.Fprintf(b, "%s}\n", indent)

Comment thread workflow/visualization.go
Comment on lines +174 to +197
func reflectUniqueEdges(wf *Workflow) []EdgeInfo {
seen := map[string]bool{}
var out []EdgeInfo
for _, list := range wf.ReflectEdges() {
for _, info := range list {
key := edgeSignature(info)
if seen[key] {
continue
}
seen[key] = true
out = append(out, info)
}
}
sort.Slice(out, func(i, j int) bool {
return edgeSignature(out[i]) < edgeSignature(out[j])
})
return out
}

func edgeSignature(info EdgeInfo) string {
return strings.Join(info.Connection.SourceIDs, ",") + ">" +
strings.Join(info.Connection.SinkIDs, ",") + "|" +
info.Label + "|" + strconv.FormatBool(info.HasCondition)
}
Comment thread workflow/visualization.go
Comment on lines +58 to +63
if sub, ok := binding.RawValue.(*Workflow); ok && sub != nil {
fmt.Fprintf(b, "%ssubgraph %s [\"%s\"]\n", indent, nodeID, mermaidLabel(id))
writeMermaidWorkflow(b, sub, prefix+id+"/", depth+1, visited)
fmt.Fprintf(b, "%send\n", indent)
continue
}
Comment thread workflow/visualization.go
Comment on lines +223 to +225
func mermaidLabel(s string) string {
return strings.ReplaceAll(s, "\"", "#quot;")
}
@github-actions

This comment has been minimized.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by Go API Consistency Review Agent · sonnet46 · 52.7 AIC · ⌖ 5.07 AIC · ⊞ 5.7K

Comment thread workflow/visualization.go
}

func writeMermaidEdge(b *strings.Builder, indent, prefix string, info EdgeInfo) {
sources := info.Connection.SourceIDs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — Mermaid fan-in node shape: Go renders fan-in nodes as {{"fan-in"}} (hexagon). Both .NET (WorkflowVisualizer) and Python (WorkflowViz) use ((fan-in)) — the circle/stadium shape — for this node, producing a visually different diagram.

.NET: lines.Add($"{indent}{GetSafeId(nodeId)}((fan-in))");
Python: lines.append(f"{indent}{fan_node_id}((fan-in))")

Suggested fix: change {{"fan-in"}}((fan-in)).

Comment thread workflow/visualization.go
if len(sources) > 1 {
junction := mermaidID(prefix + fanInJunctionID(sources, sinks))
fmt.Fprintf(b, "%s%s{{\"fan-in\"}}\n", indent, junction)
for _, s := range sources {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — Mermaid conditional edge default label missing: When a conditional edge has no explicit label, Go emits the edge with no label text. Both .NET and Python always emit "conditional" as the default label text for conditional edges.

.NET: string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
Python: lines.append(f"{indent}{s} -. conditional .-> {t};")

If the Go EdgeInfo.Label is empty and HasCondition is true, the edge label should fall back to "conditional" for visual parity.

Comment thread workflow/visualization.go
fmt.Fprintf(b, "%s}\n", indent)
continue
}
if id == wf.startExecutorID {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — DOT fan-in node shape/color: Go uses shape=diamond for fan-in junction nodes. Both .NET and Python use shape=ellipse, fillcolor=lightgoldenrod for these nodes.

.NET: lines.Add($"{indent}{GetSafeId(nodeId)}" [shape=ellipse, fillcolor=lightgoldenrod, label="fan-in"]");
Python: lines.append(f'{indent}"{map_id(node_id)}" [shape=ellipse, fillcolor=lightgoldenrod, label="fan-in"];')

Also note: .NET and Python emit a default node [shape=box, style=filled, fillcolor=lightblue] for all DOT nodes, which Go omits. This means regular executor nodes render without fill in Go output.

Suggested fixes:

  1. Change shape=diamondshape=ellipse, fillcolor=lightgoldenrod
  2. Add node [shape=box, style=filled, fillcolor=lightblue]; to the DOT header (alongside existing node [shape=box]).

Comment thread workflow/visualization.go
return
}
visited[wf] = true
indent := strings.Repeat(" ", depth)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — DOT start node label format: Go labels the start node as "id" (just the executor ID). Both .NET and Python label it as "id (Start)" to visually identify it as the entry point.

.NET: lines.Add($"{indent}\"{MapId(startExecutorId)}\" [fillcolor=lightgreen, label=\"{startExecutorId}\n(Start)\"];");
Python: lines.append(f'{indent}"{map_id(start_executor_id)}" [fillcolor=lightgreen, label="{start_executor_id}\n(Start)"];')

Also, the start node fill color differs: Go uses #2E7D32 (dark green), while both upstream SDKs use lightgreen.

Suggested fix: change start node attributes to style=filled, fillcolor=lightgreen and append (Start) to the label.

Comment thread workflow/visualization.go
return "n"
}
if out[0] >= '0' && out[0] <= '9' {
return "n" + out

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — Mermaid label escaping is incomplete: mermaidLabel only escapes "#quot;. Both .NET and Python escape a fuller set of characters that have special meaning in Mermaid syntax.

.NET escapes: &&, ||, "&quot;, <<, >>, <br/>
Python mirrors the same set.

Of these, | is the most critical: an unescaped pipe in a label string will break Mermaid's edge-label delimiter syntax and produce invalid output. Please align mermaidLabel with the upstream escaping.

Comment thread workflow/visualization.go
if seen[key] {
continue
}
seen[key] = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential stability issue — fan-in junction ID uses name concatenation instead of a hash: fanInJunctionID builds its ID by concatenating source/sink names with _ and __. Both .NET and Python derive the junction node ID from a short SHA-256 digest of the sorted source names and target, which avoids collisions when executor IDs are long, contain underscores, or differ only in ordering.

.NET: var digest = ComputeFanInDigest(target, sources); (SHA-256, first 8 hex chars)
Python: hashlib.sha256((target + "|".join(sources)).encode()).hexdigest()[:8]

With the current approach, two different fan-in configurations can produce the same junction ID if their concatenated strings happen to match, silently corrupting the graph. Consider adopting the same digest-based approach.

@github-actions github-actions Bot added area:workflow Changes files in the workflow area size:xlarge More than 300 changed lines or 10 files pending-auto-risk Automatic risk classification is in progress labels Aug 20, 2026
@github-actions

This comment has been minimized.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by Go API Consistency Review Agent · sonnet46 · 38.5 AIC · ⌖ 5.64 AIC · ⊞ 6K

Comment thread workflow/visualization.go
}
return
}
for _, s := range sources {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — fan-in node shape (Mermaid): Go emits a hexagon {{"fan-in"}} for fan-in junction nodes, but upstream .NET WorkflowVisualizer uses a circle/stadium ((fan-in)) (dotnet source). The visual convention should match: please change the Mermaid fan-in shape to (("fan-in")).

Comment thread workflow/visualization.go
junction := mermaidID(prefix + fanInJunctionID(sources, sinks))
fmt.Fprintf(b, "%s%s{{\"fan-in\"}}\n", indent, junction)
for _, s := range sources {
fmt.Fprintf(b, "%s%s --> %s\n", indent, mermaidID(prefix+s), junction)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — conditional edge default label (Mermaid): .NET emits a default label "conditional" on conditional edges that have no user-supplied label, using the syntax -. conditional .-> (dotnet source). Go currently emits no label and uses -.-> syntax. Please add the default "conditional" label when info.Label == "" and use the -. label .-> Mermaid syntax to match upstream.

Comment thread workflow/visualization.go
fmt.Fprintf(b, "%send\n", indent)
continue
}
fmt.Fprintf(b, "%s%s[\"%s\"]\n", indent, nodeID, mermaidLabel(id))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — start node label: .NET appends (Start) to the start executor label in both Mermaid and DOT (e.g. "myExecutor\n(Start)"). Go emits only the executor ID. This makes it harder to distinguish the start node from other nodes purely by label. Please append \n(Start) (DOT) or (Start) (Mermaid) to the start node label to match upstream.

Comment thread workflow/visualization.go
sinks := info.Connection.SinkIDs
var attrParts []string
if info.Label != "" {
attrParts = append(attrParts, fmt.Sprintf("label=\"%s\"", dotEscape(info.Label)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — DOT fan-in node shape: Go uses shape=diamond for fan-in junction nodes. Upstream .NET uses shape=ellipse (dotnet source). Please change to shape=ellipse to preserve visual parity.

Comment thread workflow/visualization.go
if visited[wf] {
return
}
visited[wf] = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — DOT global node style: .NET emits node [shape=box, style=filled, fillcolor=lightblue]; so all nodes get a light-blue fill by default, with the start node overriding to fillcolor=lightgreen. Go omits the global style and only fills the start node. Please add a global node style matching the .NET convention so non-start nodes are also filled.

Comment thread workflow/visualization.go
func mermaidLabel(s string) string {
return strings.ReplaceAll(s, "\"", "#quot;")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — Mermaid label escaping: mermaidLabel only escapes " (as #quot;). Upstream .NET EscapeMermaidLabel also escapes &&, ||, <<, >>, \n<br/>, and strips \r (dotnet source). Unescaped | and </> will break Mermaid parsing for executor names containing those characters. Please bring mermaidLabel in line with the full upstream escaping set.

@github-actions github-actions Bot added failed-auto-risk Automatic risk classification was inconclusive or failed and removed pending-auto-risk Automatic risk classification is in progress labels Aug 20, 2026
@github-actions github-actions Bot added pending-auto-risk Automatic risk classification is in progress risk:medium Contained production impact requiring normal review depth and removed failed-auto-risk Automatic risk classification was inconclusive or failed pending-auto-risk Automatic risk classification is in progress labels Aug 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Parity Review — PR #633: Workflow Mermaid/DOT Visualization

This PR is in scope (adds exported public API: ToMermaidString, ToDotString). The public-api-change label is already present.

The feature concept is well-aligned with upstream: both .NET (WorkflowVisualizer.cs) and Python (_viz.py) ship exactly ToMermaidString / to_mermaid() and ToDotString / to_digraph(). The Go free-function shape is closer to .NET's static extension methods than Python's WorkflowViz class.

However, four parity issues were found:

1. Missing include_internal_executors option (Python parity gap)

Python's to_mermaid(include_internal_executors=False) and to_digraph(include_internal_executors=False) let callers opt in to showing framework-internal executors; the default is False (hidden). Go exposes no equivalent. This is the most significant gap — callers lose a Python-parity feature and there is no way to get the same default behavior if Go's reflection helpers expose internal executors unconditionally. See inline comment on line 14.

2. Conditional edge default label in Mermaid output

When a conditional edge has no custom label, .NET emits -. conditional .-> and Python emits -. conditional .->. Go emits -.-> (no label text). The output is visually less informative and diverges from the upstream convention. See inline comment on line 69.

3. DOT start-node styling (label and fill color)

.NET and Python both emit fillcolor=lightgreen with label="<id>\n(Start)". Go emits fillcolor="#2E7D32", fontcolor="white" and omits the (Start) label suffix on the DOT path (Mermaid uses a classDef). See inline comment on line 97.

4. DOT fan-in junction node shape

.NET and Python both emit shape=ellipse, fillcolor=lightgoldenrod for fan-in junction nodes. Go uses shape=diamond with no fill color. See inline comment on line 113.


Not flagged: The API shape (free functions vs. class instance vs. extension methods) is an intentional language-idiom difference and acceptable. The rankdir=TB vs rankdir=TD synonym is harmless. The Mermaid classDef startNode approach is Go-idiomatic and not a parity issue by itself.

Parity cannot be approved until the four items above are resolved or explicitly accepted as intentional divergences.

Generated by Go API Consistency Review Agent · sonnet46 · 56.2 AIC · ⌖ 4.72 AIC · ⊞ 6K ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by Go API Consistency Review Agent · sonnet46 · 56.2 AIC · ⌖ 4.72 AIC · ⊞ 6K

Comment thread workflow/visualization.go

// ToMermaidString renders wf as a Mermaid flowchart definition.
//
// It mirrors .NET's WorkflowVisualizer.ToMermaidString: nodes are emitted for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap: include_internal_executors option missing

The Python WorkflowViz.to_digraph() and to_mermaid() both accept include_internal_executors: bool = False (see _viz.py). This parameter controls whether internal (framework-managed) executors are included in the rendered graph. Its default is False, meaning internal executors are hidden by default.

The Go ToMermaidString and ToDotString functions have no equivalent option. Callers cannot opt in to showing internal executors, and there is no parity with the Python default of excluding them if Go's reflection helpers surface internal executors unconditionally.

Suggestion: introduce a VisualizationOptions struct (or a simple boolean) defaulting to false, matching the Python default. Note that the .NET WorkflowVisualizer also does not expose this parameter — that cross-SDK gap already exists upstream — but Go should at minimum align with Python here.

Comment thread workflow/visualization.go
binding := wf.executorBindings[id]
nodeID := prefix + id
if sub, ok := binding.RawValue.(*Workflow); ok && sub != nil {
fmt.Fprintf(b, "%ssubgraph \"cluster_%s\" {\n", indent, dotEscape(nodeID))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DOT fan-in node shape diverges from .NET

The .NET WorkflowVisualizer emits fan-in junction nodes as shape=ellipse, fillcolor=lightgoldenrod (see WorkflowVisualizer.cs). The Go implementation uses shape=diamond with no fill color. The Python implementation also uses shape=ellipse, fillcolor=lightgoldenrod.

Suggestion: change the junction node to shape=ellipse, fillcolor=lightgoldenrod to match both .NET and Python.

Comment thread workflow/visualization.go
return
}
for _, s := range sources {
for _, t := range sinks {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DOT start-node styling diverges from .NET and Python

Both .NET (WorkflowVisualizer.cs) and Python (_viz.py) emit the start node with fillcolor=lightgreen and label="<id>\n(Start)". The Go implementation uses fillcolor="#2E7D32", fontcolor="white" without the (Start) label suffix.

Suggestion: use fillcolor=lightgreen and append \n(Start) to the label to match the upstream visual convention. (The Mermaid side already appends a classDef startNode with the hex green — it's the DOT path that diverges.)

Comment thread workflow/visualization.go
fmt.Fprintf(b, "%sclass %s startNode;\n", indent, nodeID)
}
}
for _, info := range reflectUniqueEdges(wf) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conditional edge default label: Go omits "conditional" when no custom label is set

In the Mermaid output for conditional edges, .NET uses -. conditional .-> when no custom label is set (WorkflowVisualizer.cs). Python likewise emits -. conditional .-> for unlabeled conditional edges.

The Go writeMermaidEdge uses "-.->" without inserting any label text when info.Label == "". This means an unlabeled conditional edge in Go renders as A -.-> B instead of A -. conditional .-> B.

Suggestion: When info.HasCondition && info.Label == "", default the label to "conditional" to match .NET and Python.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:workflow Changes files in the workflow area public-api-change Pull Request changes public APIs risk:medium Contained production impact requiring normal review depth size:xlarge More than 300 changed lines or 10 files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants